Merge pull request #328 from knu/imap_folder_agent

Implement ImapFolderAgent.

Andrew Cantino 10 years ago
parent
commit
3e6920d49d

+ 453 - 0
app/models/agents/imap_folder_agent.rb

@@ -0,0 +1,453 @@
1
+require 'delegate'
2
+require 'net/imap'
3
+require 'mail'
4
+
5
+module Agents
6
+  class ImapFolderAgent < Agent
7
+    cannot_receive_events!
8
+
9
+    default_schedule "every_30m"
10
+
11
+    description <<-MD
12
+
13
+      The ImapFolderAgent checks an IMAP server in specified folders
14
+      and creates Events based on new unread mails.
15
+
16
+      Specify an IMAP server to connect with `host`, and set `ssl` to
17
+      true if the server supports IMAP over SSL.  Specify `port` if
18
+      you need to connect to a port other than standard (143 or 993
19
+      depending on the `ssl` value).
20
+
21
+      Specify login credentials in `username` and `password`.
22
+
23
+      List the names of folders to check in `folders`.
24
+
25
+      To narrow mails by conditions, build a `conditions` hash with
26
+      the following keys:
27
+
28
+      - "subject"
29
+      - "body"
30
+
31
+          Specify a regular expression to match against the decoded
32
+          subject/body of each mail.
33
+
34
+          Use the `(?i)` directive for case-insensitive search.  For
35
+          example, a pattern `(?i)alert` will match "alert", "Alert"
36
+          or "ALERT".  You can also make only a part of a pattern to
37
+          work case-insensitively: `Re: (?i:alert)` will match either
38
+          "Re: Alert" or "Re: alert", but not "RE: alert".
39
+
40
+          When a mail has multiple non-attachment text parts, they are
41
+          prioritized according to the `mime_types` option (which see
42
+          below) and the first part that matches a "body" pattern, if
43
+          specified, will be chosen as the "body" value in a created
44
+          event.
45
+
46
+          Named captues will appear in the "matches" hash in a created
47
+          event.
48
+
49
+      - "from", "to", "cc"
50
+
51
+          Specify a shell glob pattern string that is matched against
52
+          mail addresses extracted from the corresponding header
53
+          values of each mail.
54
+
55
+          Patterns match addresses in case insensitive manner.
56
+
57
+          Multiple pattern strings can be specified in an array, in
58
+          which case a mail is selected if any of the patterns
59
+          matches. (i.e. patterns are OR'd)
60
+
61
+      - "mime_types"
62
+
63
+          Specify an array of MIME types to tell which non-attachment
64
+          part of a mail among its text/* parts should be used as mail
65
+          body.  The default value is `['text/plain', 'text/enriched',
66
+          'text/html']`.
67
+
68
+      - "has_attachment"
69
+
70
+          Setting this to true or false means only mails that does or does
71
+          not have an attachment are selected.
72
+
73
+          If this key is unspecified or set to null, it is ignored.
74
+
75
+      Set `mark_as_read` to true to mark found mails as read.
76
+
77
+      Each agent instance memorizes a list of unread mails that are
78
+      found in the last run, so even if you change a set of conditions
79
+      so that it matches mails that are missed previously, they will
80
+      not show up as new events.  Also, in order to avoid duplicated
81
+      notification it keeps a list of Message-Id's of 100 most recent
82
+      mails, so if multiple mails of the same Message-Id are found,
83
+      you will only see one event out of them.
84
+    MD
85
+
86
+    event_description <<-MD
87
+      Events look like this:
88
+
89
+          {
90
+            "folder": "INBOX",
91
+            "subject": "...",
92
+            "from": "Nanashi <nanashi.gombeh@example.jp>",
93
+            "to": ["Jane <jane.doe@example.com>"],
94
+            "cc": [],
95
+            "date": "2014-05-10T03:47:20+0900",
96
+            "mime_type": "text/plain",
97
+            "body": "Hello,\n\n...",
98
+            "matches": {
99
+            }
100
+          }
101
+    MD
102
+
103
+    IDCACHE_SIZE = 100
104
+
105
+    FNM_FLAGS = [:FNM_CASEFOLD, :FNM_EXTGLOB].inject(0) { |flags, sym|
106
+      if File.const_defined?(sym)
107
+        flags | File.const_get(sym)
108
+      else
109
+        flags
110
+      end
111
+    }
112
+
113
+    def working?
114
+      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
115
+    end
116
+
117
+    def default_options
118
+      {
119
+        'expected_update_period_in_days' => "1",
120
+        'host' => 'imap.gmail.com',
121
+        'ssl' => true,
122
+        'username' => 'your.account',
123
+        'password' => 'your.password',
124
+        'folders' => %w[INBOX],
125
+        'conditions' => {}
126
+      }
127
+    end
128
+
129
+    def validate_options
130
+      %w[host username password].each { |key|
131
+        String === options[key] or
132
+          errors.add(:base, '%s is required and must be a string' % key)
133
+      }
134
+
135
+      if options['port'].present?
136
+        errors.add(:base, "port must be a positive integer") unless is_positive_integer?(options['port'])
137
+      end
138
+
139
+      %w[ssl mark_as_read].each { |key|
140
+        if options[key].present?
141
+          case options[key]
142
+          when true, false
143
+          else
144
+            errors.add(:base, '%s must be a boolean value' % key)
145
+          end
146
+        end
147
+      }
148
+
149
+      case mime_types = options['mime_types']
150
+      when nil
151
+      when Array
152
+        mime_types.all? { |mime_type|
153
+          String === mime_type && mime_type.start_with?('text/')
154
+        } or errors.add(:base, 'mime_types may only contain strings that match "text/*".')
155
+        if mime_types.empty?
156
+          errors.add(:base, 'mime_types should not be empty')
157
+        end
158
+      else
159
+        errors.add(:base, 'mime_types must be an array')
160
+      end
161
+
162
+      case folders = options['folders']
163
+      when nil
164
+      when Array
165
+        folders.all? { |folder|
166
+          String === folder
167
+        } or errors.add(:base, 'folders may only contain strings')
168
+        if folders.empty?
169
+          errors.add(:base, 'folders should not be empty')
170
+        end
171
+      else
172
+        errors.add(:base, 'folders must be an array')
173
+      end
174
+
175
+      case conditions = options['conditions']
176
+      when nil
177
+      when Hash
178
+        conditions.each { |key, value|
179
+          value.present? or next
180
+          case key
181
+          when 'subject', 'body'
182
+            case value
183
+            when String
184
+              begin
185
+                Regexp.new(value)
186
+              rescue
187
+                errors.add(:base, 'conditions.%s contains an invalid regexp' % key)
188
+              end
189
+            else
190
+              errors.add(:base, 'conditions.%s contains a non-string object' % key)
191
+            end
192
+          when 'from', 'to', 'cc'
193
+            Array(value).each { |pattern|
194
+              case pattern
195
+              when String
196
+                begin
197
+                  glob_match?(pattern, '')
198
+                rescue
199
+                  errors.add(:base, 'conditions.%s contains an invalid glob pattern' % key)
200
+                end
201
+              else
202
+                errors.add(:base, 'conditions.%s contains a non-string object' % key)
203
+              end
204
+            }
205
+          when 'has_attachment'
206
+            case value
207
+            when true, false
208
+            else
209
+              errors.add(:base, 'conditions.%s must be a boolean value or null' % key)
210
+            end
211
+          end
212
+        }
213
+      else
214
+        errors.add(:base, 'conditions must be a hash')
215
+      end
216
+
217
+      if options['expected_update_period_in_days'].present?
218
+        errors.add(:base, "Invalid expected_update_period_in_days format") unless is_positive_integer?(options['expected_update_period_in_days'])
219
+      end
220
+    end
221
+
222
+    def check
223
+      # 'seen' keeps a hash of { uidvalidity => uids, ... } which
224
+      # lists unread mails in watched folders.
225
+      seen = memory['seen'] || {}
226
+      new_seen = Hash.new { |hash, key|
227
+        hash[key] = []
228
+      }
229
+
230
+      # 'notified' keeps an array of message-ids of {IDCACHE_SIZE}
231
+      # most recent notified mails.
232
+      notified = memory['notified'] || []
233
+
234
+      each_unread_mail { |mail|
235
+        new_seen[mail.uidvalidity] << mail.uid
236
+
237
+        next if (uids = seen[mail.uidvalidity]) && uids.include?(mail.uid)
238
+
239
+        body_parts = mail.body_parts(mime_types)
240
+        matched_part = nil
241
+        matches = {}
242
+
243
+        options['conditions'].all? { |key, value|
244
+          case key
245
+          when 'subject'
246
+            value.present? or next true
247
+            re = Regexp.new(value)
248
+            if m = re.match(mail.subject)
249
+              m.names.each { |name|
250
+                matches[name] = m[name]
251
+              }
252
+              true
253
+            else
254
+              false
255
+            end
256
+          when 'body'
257
+            value.present? or next true
258
+            re = Regexp.new(value)
259
+            matched_part = body_parts.find { |part|
260
+               if m = re.match(part.decoded)
261
+                 m.names.each { |name|
262
+                   matches[name] = m[name]
263
+                 }
264
+                 true
265
+               else
266
+                 false
267
+               end
268
+            }
269
+          when 'from', 'to', 'cc'
270
+            value.present? or next true
271
+            mail.header[key].addresses.any? { |address|
272
+              Array(value).any? { |pattern|
273
+                glob_match?(pattern, address)
274
+              }
275
+            }
276
+          when 'has_attachment'
277
+            value == mail.has_attachment?
278
+          else
279
+            log 'Unknown condition key ignored: %s' % key
280
+            true
281
+          end
282
+        } or next
283
+
284
+        unless notified.include?(mail.message_id)
285
+          matched_part ||= body_parts.first
286
+
287
+          if matched_part
288
+            mime_type = matched_part.mime_type
289
+            body = matched_part.decoded
290
+          else
291
+            mime_type = 'text/plain'
292
+            body = ''
293
+          end
294
+
295
+          create_event :payload => {
296
+            'folder' => mail.folder,
297
+            'subject' => mail.subject,
298
+            'from' => mail.from_addrs.first,
299
+            'to' => mail.to_addrs,
300
+            'cc' => mail.cc_addrs,
301
+            'date' => (mail.date.iso8601 rescue nil),
302
+            'mime_type' => mime_type,
303
+            'body' => body,
304
+            'matches' => matches,
305
+            'has_attachment' => mail.has_attachment?,
306
+          }
307
+
308
+          notified << mail.message_id if mail.message_id
309
+        end
310
+
311
+        if options['mark_as_read']
312
+          log 'Marking as read'
313
+          mail.mark_as_read
314
+        end
315
+      }
316
+
317
+      notified.slice!(0...-IDCACHE_SIZE) if notified.size > IDCACHE_SIZE
318
+
319
+      memory['seen'] = new_seen
320
+      memory['notified'] = notified
321
+      save!
322
+    end
323
+
324
+    def each_unread_mail
325
+      host, port, ssl, username = options.values_at(:host, :port, :ssl, :username)
326
+
327
+      log "Connecting to #{host}#{':%d' % port if port}#{' via SSL' if ssl}"
328
+      Client.open(host, Integer(port), ssl) { |imap|
329
+        log "Logging in as #{username}"
330
+        imap.login(username, options[:password])
331
+
332
+        options['folders'].each { |folder|
333
+          log "Selecting the folder: %s" % folder
334
+
335
+          imap.select(folder)
336
+
337
+          unseen = imap.search('UNSEEN')
338
+
339
+          if unseen.empty?
340
+            log "No unread mails"
341
+            next
342
+          end
343
+
344
+          imap.fetch_mails(unseen).each { |mail|
345
+            yield mail
346
+          }
347
+        }
348
+      }
349
+    ensure
350
+      log 'Connection closed'
351
+    end
352
+
353
+    def mime_types
354
+      options['mime_types'] || %w[text/plain text/enriched text/html]
355
+    end
356
+
357
+    private
358
+
359
+    def is_positive_integer?(value)
360
+      Integer(value) >= 0
361
+    rescue
362
+      false
363
+    end
364
+
365
+    def glob_match?(pattern, value)
366
+      File.fnmatch?(pattern, value, FNM_FLAGS)
367
+    end
368
+
369
+    class Client < ::Net::IMAP
370
+      class << self
371
+        def open(host, port, ssl)
372
+          imap = new(host, port, ssl)
373
+          yield imap
374
+        ensure
375
+          imap.disconnect
376
+        end
377
+      end
378
+
379
+      def select(folder)
380
+        ret = super(@folder = folder)
381
+        @uidvalidity = responses['UIDVALIDITY'].last
382
+        ret
383
+      end
384
+
385
+      def fetch_mails(set)
386
+        fetch(set, %w[UID RFC822.HEADER]).map { |data|
387
+          Message.new(self, data, folder: @folder, uidvalidity: @uidvalidity)
388
+        }
389
+      end
390
+    end
391
+
392
+    class Message < SimpleDelegator
393
+      DEFAULT_BODY_MIME_TYPES = %w[text/plain text/enriched text/html]
394
+
395
+      attr_reader :uid, :folder, :uidvalidity
396
+
397
+      def initialize(client, fetch_data, props = {})
398
+        @client = client
399
+        props.each { |key, value|
400
+          instance_variable_set(:"@#{key}", value)
401
+        }
402
+        attr = fetch_data.attr
403
+        @uid = attr['UID']
404
+        super(Mail.read_from_string(attr['RFC822.HEADER']))
405
+      end
406
+
407
+      def has_attachment?
408
+        @has_attachment ||=
409
+          begin
410
+            data = @client.uid_fetch(@uid, 'BODYSTRUCTURE').first
411
+            struct_has_attachment?(data.attr['BODYSTRUCTURE'])
412
+          end
413
+      end
414
+
415
+      def fetch
416
+        @parsed ||=
417
+          begin
418
+            data = @client.uid_fetch(@uid, 'BODY.PEEK[]').first
419
+            Mail.read_from_string(data.attr['BODY[]'])
420
+          end
421
+      end
422
+
423
+      def body_parts(mime_types = DEFAULT_BODY_MIME_TYPES)
424
+        mail = fetch
425
+        if mail.multipart?
426
+          mail.body.set_sort_order(mime_types)
427
+          mail.body.sort_parts!
428
+          mail.all_parts
429
+        else
430
+          [mail]
431
+        end.reject { |part|
432
+          part.multipart? || part.attachment? || !part.text? ||
433
+            !mime_types.include?(part.mime_type)
434
+        }
435
+      end
436
+
437
+      def mark_as_read
438
+        @client.uid_store(@uid, '+FLAGS', [:Seen])
439
+      end
440
+
441
+      private
442
+
443
+      def struct_has_attachment?(struct)
444
+        struct.multipart? && (
445
+          struct.subtype == 'MIXED' ||
446
+          struct.parts.any? { |part|
447
+            struct_has_attachment?(part)
448
+          }
449
+        )
450
+      end
451
+    end
452
+  end
453
+end

+ 22 - 0
spec/data_fixtures/imap1.eml

@@ -0,0 +1,22 @@
1
+From: Nanashi <nanashi.gombeh@example.jp>
2
+Date: Fri, 9 May 2014 16:00:00 +0900
3
+Message-ID: <foo.123@mail.example.jp>
4
+Subject: some subject
5
+To: Jane <jane.doe@example.com>, John <john.doe@example.com>
6
+MIME-Version: 1.0
7
+Content-Type: multipart/alternative; boundary=d8c92622e09101e4bc833685557b
8
+
9
+--d8c92622e09101e4bc833685557b
10
+Content-Type: text/plain; charset=UTF-8
11
+
12
+Some plain text
13
+Some second line
14
+
15
+--d8c92622e09101e4bc833685557b
16
+Content-Type: text/html; charset=UTF-8
17
+Content-Transfer-Encoding: quoted-printable
18
+
19
+<div dir=3D"ltr">Some HTML document<br>
20
+Some second line of HTML<br></div>
21
+
22
+--d8c92622e09101e4bc833685557b--

+ 20 - 0
spec/data_fixtures/imap2.eml

@@ -0,0 +1,20 @@
1
+From: John <john.doe@example.com>
2
+Date: Fri, 9 May 2014 17:00:00 +0900
3
+Message-ID: <bar.456@mail.example.com>
4
+Subject: Re: some subject
5
+To: Jane <jane.doe@example.com>, Nanashi <nanashi.gombeh@example.jp>
6
+MIME-Version: 1.0
7
+Content-Type: multipart/alternative; boundary=d8c92622e09101e4bc833685557b
8
+
9
+--d8c92622e09101e4bc833685557b
10
+Content-Type: text/plain; charset=UTF-8
11
+
12
+Some reply
13
+
14
+--d8c92622e09101e4bc833685557b
15
+Content-Type: text/html; charset=UTF-8
16
+Content-Transfer-Encoding: quoted-printable
17
+
18
+<div dir=3D"ltr">Some HTML reply<br></div>
19
+
20
+--d8c92622e09101e4bc833685557b--

+ 242 - 0
spec/models/agents/imap_folder_agent_spec.rb

@@ -0,0 +1,242 @@
1
+require 'spec_helper'
2
+require 'time'
3
+
4
+describe Agents::ImapFolderAgent do
5
+  describe 'checking IMAP' do
6
+    before do
7
+      @site = {
8
+        'expected_update_period_in_days' => 1,
9
+        'host' => 'mail.example.net',
10
+        'ssl' => true,
11
+        'username' => 'foo',
12
+        'password' => 'bar',
13
+        'folders' => ['INBOX'],
14
+        'conditions' => {
15
+        }
16
+      }
17
+      @checker = Agents::ImapFolderAgent.new(:name => 'Example', :options => @site, :keep_events_for => 2)
18
+      @checker.user = users(:bob)
19
+      @checker.save!
20
+
21
+      message_mixin = Module.new {
22
+        def folder
23
+          'INBOX'
24
+        end
25
+
26
+        def uidvalidity
27
+          '100'
28
+        end
29
+
30
+        def has_attachment?
31
+          false
32
+        end
33
+
34
+        def body_parts(mime_types = %[text/plain text/enriched text/html])
35
+          mime_types.map { |type|
36
+            all_parts.find { |part|
37
+              part.mime_type == type
38
+            }
39
+          }.compact
40
+        end
41
+      }
42
+
43
+      @mails = [
44
+        Mail.read(Rails.root.join('spec/data_fixtures/imap1.eml')).tap { |mail|
45
+          mail.extend(message_mixin)
46
+          stub(mail).uid.returns(1)
47
+        },
48
+        Mail.read(Rails.root.join('spec/data_fixtures/imap2.eml')).tap { |mail|
49
+          mail.extend(message_mixin)
50
+          stub(mail).uid.returns(2)
51
+          stub(mail).has_attachment?.returns(true)
52
+        },
53
+      ]
54
+
55
+      stub(@checker).each_unread_mail.returns { |yielder|
56
+        @mails.each(&yielder)
57
+      }
58
+
59
+      @payloads = [
60
+        {
61
+          'folder' => 'INBOX',
62
+          'from' => 'nanashi.gombeh@example.jp',
63
+          'to' => ['jane.doe@example.com', 'john.doe@example.com'],
64
+          'cc' => [],
65
+          'date' => '2014-05-09T16:00:00+09:00',
66
+          'subject' => 'some subject',
67
+          'body' => "Some plain text\nSome second line\n",
68
+          'has_attachment' => false,
69
+          'matches' => {},
70
+          'mime_type' => 'text/plain',
71
+        },
72
+        {
73
+          'folder' => 'INBOX',
74
+          'from' => 'john.doe@example.com',
75
+          'to' => ['jane.doe@example.com', 'nanashi.gombeh@example.jp'],
76
+          'cc' => [],
77
+          'subject' => 'Re: some subject',
78
+          'body' => "Some reply\n",
79
+          'date' => '2014-05-09T17:00:00+09:00',
80
+          'has_attachment' => true,
81
+          'matches' => {},
82
+          'mime_type' => 'text/plain',
83
+        }
84
+      ]
85
+    end
86
+
87
+    describe 'validations' do
88
+      before do
89
+        @checker.should be_valid
90
+      end
91
+
92
+      it 'should validate the integer fields' do
93
+        @checker.options['expected_update_period_in_days'] = 'nonsense'
94
+        @checker.should_not be_valid
95
+
96
+        @checker.options['expected_update_period_in_days'] = '2'
97
+        @checker.should be_valid
98
+
99
+        @checker.options['port'] = -1
100
+        @checker.should_not be_valid
101
+
102
+        @checker.options['port'] = 'imap'
103
+        @checker.should_not be_valid
104
+
105
+        @checker.options['port'] = '143'
106
+        @checker.should be_valid
107
+
108
+        @checker.options['port'] = 993
109
+        @checker.should be_valid
110
+      end
111
+
112
+      it 'should validate the boolean fields' do
113
+        @checker.options['ssl'] = false
114
+        @checker.should be_valid
115
+
116
+        @checker.options['ssl'] = 'true'
117
+        @checker.should_not be_valid
118
+      end
119
+
120
+      it 'should validate regexp conditions' do
121
+        @checker.options['conditions'] = {
122
+          'subject' => '(foo'
123
+        }
124
+        @checker.should_not be_valid
125
+
126
+        @checker.options['conditions'] = {
127
+          'body' => '***'
128
+        }
129
+        @checker.should_not be_valid
130
+
131
+        @checker.options['conditions'] = {
132
+          'subject' => '\ARe:',
133
+          'body' => '(?<foo>http://\S+)'
134
+        }
135
+        @checker.should be_valid
136
+      end
137
+    end
138
+
139
+    describe '#check' do
140
+      it 'should check for mails and save memory' do
141
+        lambda { @checker.check }.should change { Event.count }.by(2)
142
+        @checker.memory['notified'].sort.should == @mails.map(&:message_id).sort
143
+        @checker.memory['seen'].should == @mails.each_with_object({}) { |mail, seen|
144
+          (seen[mail.uidvalidity] ||= []) << mail.uid
145
+        }
146
+
147
+        Event.last(2).map(&:payload) == @payloads
148
+
149
+        lambda { @checker.check }.should_not change { Event.count }
150
+      end
151
+
152
+      it 'should narrow mails by To' do
153
+        @checker.options['conditions']['to'] = 'John.Doe@*'
154
+
155
+        lambda { @checker.check }.should change { Event.count }.by(1)
156
+        @checker.memory['notified'].sort.should == [@mails.first.message_id]
157
+        @checker.memory['seen'].should == @mails.each_with_object({}) { |mail, seen|
158
+          (seen[mail.uidvalidity] ||= []) << mail.uid
159
+        }
160
+
161
+        Event.last.payload.should == @payloads.first
162
+
163
+        lambda { @checker.check }.should_not change { Event.count }
164
+      end
165
+
166
+      it 'should perform regexp matching and save named captures' do
167
+        @checker.options['conditions'].update(
168
+          'subject' => '\ARe: (?<a>.+)',
169
+          'body'    => 'Some (?<b>.+) reply',
170
+        )
171
+
172
+        lambda { @checker.check }.should change { Event.count }.by(1)
173
+        @checker.memory['notified'].sort.should == [@mails.last.message_id]
174
+        @checker.memory['seen'].should == @mails.each_with_object({}) { |mail, seen|
175
+          (seen[mail.uidvalidity] ||= []) << mail.uid
176
+        }
177
+
178
+        Event.last.payload.should == @payloads.last.update(
179
+          'body' => "<div dir=\"ltr\">Some HTML reply<br></div>\n",
180
+          'matches' => { 'a' => 'some subject', 'b' => 'HTML' },
181
+          'mime_type' => 'text/html',
182
+        )
183
+
184
+        lambda { @checker.check }.should_not change { Event.count }
185
+      end
186
+
187
+      it 'should narrow mails by has_attachment (true)' do
188
+        @checker.options['conditions']['has_attachment'] = true
189
+
190
+        lambda { @checker.check }.should change { Event.count }.by(1)
191
+
192
+        Event.last.payload['subject'].should == 'Re: some subject'
193
+      end
194
+
195
+      it 'should narrow mails by has_attachment (false)' do
196
+        @checker.options['conditions']['has_attachment'] = false
197
+
198
+        lambda { @checker.check }.should change { Event.count }.by(1)
199
+
200
+        Event.last.payload['subject'].should == 'some subject'
201
+      end
202
+
203
+      it 'should narrow mail parts by MIME types' do
204
+        @checker.options['mime_types'] = %w[text/plain]
205
+        @checker.options['conditions'].update(
206
+          'subject' => '\ARe: (?<a>.+)',
207
+          'body'    => 'Some (?<b>.+) reply',
208
+        )
209
+
210
+        lambda { @checker.check }.should_not change { Event.count }
211
+        @checker.memory['notified'].sort.should == []
212
+        @checker.memory['seen'].should == @mails.each_with_object({}) { |mail, seen|
213
+          (seen[mail.uidvalidity] ||= []) << mail.uid
214
+        }
215
+      end
216
+
217
+      it 'should never mark mails as read unless mark_as_read is true' do
218
+        @mails.each { |mail|
219
+          stub(mail).mark_as_read.never
220
+        }
221
+        lambda { @checker.check }.should change { Event.count }.by(2)
222
+      end
223
+
224
+      it 'should mark mails as read if mark_as_read is true' do
225
+        @checker.options['mark_as_read'] = true
226
+        @mails.each { |mail|
227
+          stub(mail).mark_as_read.once
228
+        }
229
+        lambda { @checker.check }.should change { Event.count }.by(2)
230
+      end
231
+
232
+      it 'should create just one event for multiple mails with the same Message-Id' do
233
+        @mails.first.message_id = @mails.last.message_id
234
+        @checker.options['mark_as_read'] = true
235
+        @mails.each { |mail|
236
+          stub(mail).mark_as_read.once
237
+        }
238
+        lambda { @checker.check }.should change { Event.count }.by(1)
239
+      end
240
+    end
241
+  end
242
+end